环境:Nginx 1.28.3 + 宝塔面板 + Halo 2.x + Cloudflare

把博客从 Typecho 搬到 Halo 2.x,Docker Compose 跑起来,反代配好,心想总算能歇了。

结果一接 Cloudflare,控制台开始疯狂报红。再一试跨站嵌入,直接给我拒了。两个坑连环爆,折腾了一晚上,记录一下,下次别再踩了。

动漫版头图,可爱吧
动漫版头图,可爱吧

第一坑:HSTS 重复输出,Cloudflare 和我各说各话

咋回事呢?

我习惯在 Nginx 里开 HSTS,强制走 HTTPS:

add_header Strict-Transport-Security "max-age=31536000" always;

接入 Cloudflare 后,浏览器开始骂街——Duplicate Header。

因为 Cloudflare 边缘节点本身就会统一添加 HSTS 头,回源时我再加一次,就撞车了。一个响应头出现两次,浏览器很懵:你俩到底听谁的?

我第一反应:上 if 啊!

这还不简单?判断一下有没有 Cloudflare 的特征头,没有再加 HSTS 呗:

# 直觉写法,当场翻车
if ($http_cf_ray = "") {
    add_header Strict-Transport-Security "max-age=31536000" always;
}

​nginx -t​ 直接甩脸:

[emerg] "add_header" directive is not allowed here

???不让我加?

后来才知道,Nginx 的 if 出了名的邪门,江湖人称 "If is Evil"。你想在 if 里塞 add_header​?门儿都没有。这玩意儿只能在 serverlocation 里裸奔,不能塞进 if​ 的怀抱。

解法:map 大法好

既然 if 靠不住,那就把逻辑抽到 http 块,用 map 做判断(注意:map 不能放在 server 块内部):

http {
    # 动态 HSTS 智能过滤映射
    # Nginx 1.7.5+ 支持:值为空字符串时 add_header 自动隐匿
    map $http_cf_ray $dynamic_hsts {
        default "";                    # 有 cf-ray?走 Cloudflare,HSTS 留空
        ""      "max-age=31536000";    # 没 cf-ray?直连源站,上 HSTS(有效期1年)
    }
    
    server {
        # ...
        add_header Strict-Transport-Security $dynamic_hsts always;
    }
}

妙处来了: Nginx 1.7.5+ 中,add_header​ 有个隐藏特性——值是空字符串 ""​ 时,这个头直接隐身,不会出现在响应里

所以走 Cloudflare 时 $dynamic_hsts""HSTS 自动消失;直连源站时输出 max-age=31536000,完美。if​ 你不行,我 map 上,照样搞定。


第二坑:宝塔偷偷给我加了 X-Frame-Options

又是咋回事呢?

我想搞跨站嵌入,结果浏览器死活不让:

拒绝在 Frame 中加载,因为响应头包含了 X-Frame-Options: SAMEORIGIN

我明明在反代里写了:

proxy_hide_header X-Frame-Options;

Halo 后端吐的头应该被抹掉了啊,这 SAMEORIGIN 从哪冒出来的?我一度怀疑人生,是不是 Halo 在跟我作对。

排查:宝塔是内鬼

顺着配置翻啊翻,发现宝塔面板自动生成了一堆扩展 conf(注意:wuqishi.com 替换为你的实际域名):

include /www/server/panel/vhost/nginx/extension/wuqishi.com/*.conf;
include /www/server/panel/vhost/nginx/generic/wuqishi.com/*.conf;

里面静静躺着:

add_header X-Frame-Options SAMEORIGIN;

好家伙,宝塔你背刺我!

关键认知proxy_hide_header 只能藏后端服务器吐的头,对 Nginx 自己配置里写的头完全没辙。宝塔全局一加,我的反代配置根本管不到,就像你在家锁了门,结果物业在小区大门又加了一道锁。

解法:不跟宝塔纠缠,直接上 CSP

去翻宝塔文件删了?太怂,而且面板一更新可能又刷回来,跟打地鼠似的。直接在反代入口写三重保险:

# 第一重:后端来的 X-Frame-Options,抹掉
proxy_hide_header X-Frame-Options;

# 第二重:宝塔全局加的?强行重置为空,浏览器看到空值直接忽略
add_header X-Frame-Options "" always;

# 第三重:终极杀招,CSP 的 frame-ancestors 优先级碾压 X-Frame-Options
# * 表示允许所有域名嵌入,如需限制改为 https://example.com
add_header Content-Security-Policy "frame-ancestors *" always;

原理: 现代浏览器里,CSP 的 frame-ancestors 指令优先级远远高于老古董 X-Frame-Options​。看到 frame-ancestors *​,浏览器当场无视 SAMEORIGIN,跨站嵌入一路绿灯。

* 意思是 "谁都能嵌",如果你只想让特定域名嵌,如改成 frame-ancestors https://wuqishi.com 就行。但我懒,先开全通,后面再收紧。


收工,总结一下

两个坑,都是响应头的小把戏,但排查起来很费神。最后靠 mapCSP 组合拳搞定,没动业务逻辑,纯粹是边缘层的斗智斗勇。

核心解法

一句话总结

HSTS 撞车

​map动态变量

有 Cloudflare 就隐身,直连再上 HSTS

宝塔锁 Frame

CSPframe-ancestors​

优先级碾压,不跟宝塔文件纠缠

nginx -t 绿灯,配置保存不报错。响应头清清爽爽,强迫症舒服了。


解决问题的乐趣就在于此,把流量在边缘层梳理得明明白白,爽。